【老万】我在谷歌弄啥咧(十六):造泵记
昨天我提到从谷歌学到的最重要技能之一是从第一性原理(first principles)出发思考。
结果这个名词把很多朋友搞懵了。这个要怪我没说清楚,今天就再掰扯掰扯。
所谓第一性原理思维,就是从最基本的原理出发去分析,抓住问题的本质,也就是本质的问题,不接受任何预设的结论。
说人话,就是自己从头推公式,不抄作业。
我昨天还举了一个设计谷歌 C++ mocking 框架 Google Mock(又称gMock)的例子,说到 gMock 是一个嵌入在 C++ 中的DSL(特定领域语言)。
好玩的是早期的 gMock 其实包含了两个 DSL:一个在系统的表面(也就是 gMock 的 API),大家都看得见。另一个藏在 gMock 的实现里面,只有 gMock 的维护者需要了解。
为毛要在一个框架中塞入多达二个 DSL 呢?到底是中二症还是力比多用不完综合症?
要回答这个问题,咱们还得回到昨天的主题上来:大无畏精神和第一性原理思维。
我是从 2006 年开始搞 gMock 的。那时候,天苍苍,野茫茫,《老万故事会》还没有诞生,《老万故事会》的读者还有很多没有出世,汪峰们还在街上在桥下在田野中唱着那无人问津的歌谣。最重要的是:那时候的 C++ 跟今天比是一种截然不同的语言,不能说是捉襟见肘吧,起码也是一穷二白。
这就给攒 gMock 带来了巨大的挑战。毕竟,它需要利用最高级的 C++ 宏和模版特性来克服 C++ 的静态类型和缺乏反射(reflection)的问题。
我们知道,函数的参数个数有多有少:零元函数不需要参数,五毛函数需要五毛钱参数,一元函数需要一个参数,二元函数需要两个参数,依此类推。参数的个数被称为元数(arity)。
gMock 的用户需要 mock 不同元数的函数。为了支持这些需求,gMock 要根据函数的元数提供不同的实现:零元函数有一个实现,一元函数又有一个实现,等等等等。
实现这些功能,最好的办法当然是用上可变参数模板(variadic templates)和可变参数宏(variadic macros)。
然鹅问题来了,多年前的 C++ 并没有可变参数模板这样的高级功能,连可变参数宏也不好使。
最高级的食材,只需要最简单的烹饪。但要是没有高级食材,厨师就只好另辟蹊径。比如,将饭蒸熟后,加入一倍的水,再蒸一次,称为双蒸饭,可以暂时撑饱肚子。又比如,用人尿培养小球藻再让人吃下去,可以治饿痨病。
于是,gMock 的实现代码中被迫出现大量重复。我们可以选择负重前行,手动编写这些代码,但这样得到的只能是一个无法维护的系统:
写这种重复代码完全是亵渎程序员,没有一个有尊严的程序员会想维护 gMock。当然,我们有很多程序员是没有尊严的,所以这一点也不是什么大问题。
然而,这些不同元数的实现不是简单的拷贝复制,而是略有不同。复制时很容易犯错误啊啊啊。
这种方法对系统能支持的元数有人为的上限。要是想支持更多参数的函数,就必须重复更多的代码 — 这就不好玩了。
要是发现 gMock 有一个 bug,必须在多个地方修正。这种苦活既繁琐又容易出错。
聪明人可能已经想到了:干嘛自己重复写代码,写一个代码生成器不就好了吗。写完了,跑一遍,代码就生出来了。要是发现出来的代码有 bug,那就改改生成器,再跑一遍。一遍不行,就改两遍。
咋说呢,这种方法吧,不是不行,但也不是很行:
这个“代码生成器”听起来就很高大上,实际做起来确实不好维护。毕竟,C++ 和 Python 这样的通用编程语言不是专门为这一类任务设计的。如果写这么一个生成器,要改 gMock 的实现就得费老鼻子劲了。
这种半吊子方案怎么能行呢?一定还有更好的招!
我想,既然用通用语言写这个生成器不顺手,那就来个 DSL 吧。
于是有了 Pump(泵),一个用于编写可变元(variadic)C++ 代码的迷你 DSL。
Pump 这个名字是一个递归缩写:它代表 Pump is Useful for Meta Programming(Pump 对元编程有用)。它也可以代表 Pretty Useful for Meta Programming(对元编程嘿有用),或者代表 Practical Utility for Meta Programming(元编程实用工具)。
三个代表,随你心情而定,总有一款适合你。
取这个名字还有一层含义:“pump”是一个通过不断重复完成任务的动作,比如给气球打气也叫 pump。
是滴,程序员喜欢开这种傻乎乎的玩笑,乐此不疲。我就是一个典型的程序员。
由于 Pump 是专门为编写可变元 C++ 代码量身定做的,C++ 程序员学起来很简单。你可以把普通的 C++ 代码跟 Pump 特有的功能混合在一起使用。
例如,Pump 用 $ 字符开始一个 Pump 关键词,用 $$ 开始一个元注释(就是在 Pump 程序中的注释,不会出现在生成的代码中),用 [[ 和 ]] 将代码分块处理。
我们来看一个具体的 Pump 代码示例:
$var n = 3 $$ 定义一个元变量 n。
$range i 0..n $$ 声明元迭代器 i 的范围。
$for i [[
$$ 元循环。
// Foo$i 对$i元谓词执行blah操作。
$range j 1..i
template <size_t N $for j [[, typename A$j]]>
class Foo$i {
$if i == 0 [[
blah a;
]] $elif i <= 2 [[
blah b;
]] $else [[
blah c;
]]
};
]]
这段代码经 Pump 翻译后,就成了这样的 C++ 代码:
// Foo0 对0元谓词执行blah操作。
template <size_t N>
class Foo0 {
blah a;
};
// Foo1 对1元谓词执行blah操作。
template <size_t N, typename A1>
class Foo1 {
blah b;
};
// Foo2 对2元谓词执行blah操作。
template <size_t N, typename A1, typename A2>
class Foo2 {
blah b;
};
// Foo3 对3元谓词执行blah操作。
template <size_t N, typename A1, typename A2, typename A3>
class Foo3 {
blah c;
};
熟悉模板语言(templating languages)的朋友可能已经发现了:Pump 就是一个模板语言而已。
有了 Pump,gMock 中的可变元代码编写和维护就简单了。
总之,在面对 gMock 的实现需要大量重复代码的问题时,我发扬大无畏精神,没有因这个问题难解决而放弃。相反,我从第一性原理出发(想清楚 gMock 维护者到底需要什么样的工具),找到了一个办法让维护者自然地表达他们的意图,摒弃了让他们自己搞定代码生成器这种不负责任的想法。
时代的变迁总是会抹去历史的痕迹。随着 C++ 编译器对可变参数宏和可变参数模板的支持慢慢到位,Pump 也渐渐失去了存在的意义,最终迷失在黑夜里被 gMock 放弃了。今天的 gMock 已经完全不用 Pump 了。不过,互联网是有记忆的,你还是能在网上找到我为 Pump 编写的原始文档:https://github.com/google/googletest/blob/release-1.8.0/googletest/docs/PumpManual.md
~~~~
猜你会喜欢《我在谷歌弄啥咧》系列:
~~~~
关注老万故事会公众号:
本公众号不开赞赏不放广告。如果喜欢这篇文章,点个在看,转发给朋友就是对老万的最大支持。谢谢大家🙏